從今天開始,我們把視野從 Spring MVC 調整到 Spring boot 上面。首先來講講 Spring boot 最具代表性的特色之一:內嵌的 Web 容器。傳統的 Java Web 架構是「先有 Tomcat,再有 IoC 容器」,開發者需要先準備好一個外部的 Tomcat(或 JBoss),再把 war 檔放進去,由 Servlet 容器負責掛載 Spring 的相關服務;而 Spring boot 主打「一個 jar 就可以運行」,因此把這個順序倒了過來。流程改為先啟動 IoC 容器,再由容器在適當的時機自己 new 出一個 Tomcat。
負責這件事的核心介面,就是今天要講的 WebServerFactory。它本身是一個沒有定義任何方法的標記介面,真正的功能由底下的子介面分工定義:ConfigurableWebServerFactory 負責定義 port, address, ssl ...等通用設定的 setter 方法;ServletWebServerFactory 則定義了唯一的核心方法 getWebServer,負責接收一組 ServletContextInitializer,並回傳一個可以啟動與關閉的 WebServer 物件。
WebServerFactory 常見的實體類別有 3 個,分別是 TomcatServletWebServerFactory、JettyServletWebServerFactory、和 UndertowServletWebServerFactory,它們會透過 ServletWebServerFactoryAutoConfiguration 這個配置類別被匯入 IoC 容器。具體來說,配置類別會依序 @Import 3 個內部配置,每一個都掛著 @ConditionalOnClass 和 @ConditionalOnMissingBean(ServletWebServerFactory.class) 兩個條件:前者負責檢查 classpath 上有沒有對應的伺服器類別(例如 Tomcat.class),後者則確保容器內只會存在一個 ServletWebServerFactory。也因為 spring-boot-starter-web 預設就依賴了 Tomcat,所以在沒有額外調整的情況下,被創建出來的會是 TomcatServletWebServerFactory;如果想換成 Jetty,只需要在 maven 裡排除 Tomcat 的 starter、改依賴 Jetty 的 starter,就可以在不改任何程式碼的情況下完成替換。
說完了工廠本身,再來講講 application.yml 裡的 server.port 是怎麼跑到 Tomcat 身上的:Spring boot 會先把 server 開頭的設定綁定到 ServerProperties,再透過 ServletWebServerFactoryCustomizer 把 port, context-path, ssl ...等設定,逐一呼叫工廠的 setter 寫進去。這類組件都實作了 WebServerFactoryCustomizer(後續簡稱 Customizer)介面,內部只有一個 customize 方法,並透過泛型指定自己要處理哪一種工廠。真正負責調用它們的,則是一個叫做 WebServerFactoryCustomizerBeanPostProcessor 的 BeanPostProcessor:它會在工廠 Bean 的初始化前找出容器內所有的 Customizer,依照 Ordered(或 @Order)排序之後依序執行。這裡有一個小東西可以注意:ServletWebServerFactoryCustomizer 的排序值是 0,而我們自己寫的 Customizer 如果沒有特別指定排序,預設會是最低優先級、排在最後執行,因此自定義的設定會覆蓋掉 yml 的設定。此外,Spring boot 預設的 /error 錯誤頁面,也是透過另一個 ErrorPageRegistrarBeanPostProcessor 用類似的方式註冊到工廠上面的。
接著來看看工廠是在什麼時候被使用的:Spring boot 的 IoC 容器實作類別是 ServletWebServerApplicationContext,它覆寫了 refresh() 第 9 步的 onRefresh,並在裡面呼叫 createWebServer。createWebServer 會先透過 getBean 提早把工廠創建出來(這時候普通的單例 Bean 都還沒開始實例化),接著呼叫 getWebServer,並傳入一個 selfInitialize 的方法引用。以 Tomcat 為例,getWebServer 會 new 出一個 Tomcat 物件,設定好 Connector 與 Context,包裝成 TomcatWebServer 後呼叫 tomcat.start()。Tomcat 在啟動的過程中,會回頭觸發 selfInitialize,由它找出容器裡所有的 ServletContextInitializer(例如 DispatcherServlet 對應的 ServletRegistrationBean、Filter 對應的 FilterRegistrationBean ...等),並逐一註冊到 ServletContext 上,DispatcherServlet 和 Filter 就是在這個時間點被掛進 Tomcat 的。
這裡有一個值得提出來的設計細節:雖然 Tomcat 在 onRefresh 就已經 start 了,但這時候它其實還沒有開始監聽 port。原因是 TomcatWebServer 在啟動前,會先把 Connector 暫時移除,讓 Tomcat 只完成內部元件的初始化,而不進行 port 的綁定。真正的綁定會延後到 refresh() 最後的 finishRefresh:createWebServer 在建立 WebServer 的同時,會往容器註冊一個叫做 WebServerStartStopLifecycle 的 SmartLifecycle 物件,等到 finishRefresh 統一啟動 Lifecycle 的時候,它才會把 Connector 加回去,並打印出那行我們很熟悉的 Tomcat started on port 8080 日誌。這樣設計的原因也很單純:如果在 onRefresh 就開放 port,外部請求就有可能在 Controller, Service ...等 Bean 還沒創建完成之前就打進來,把綁定延後到所有單例 Bean 都完成實例化之後,才能確保第一個請求面對的是一個完整可用的服務。
最後,如果專案是打包成 war、放進外部的 Tomcat 運行,createWebServer 就會發現 ServletContext 已經存在,因此不會使用 WebServerFactory 創建新的伺服器,而是直接呼叫 selfInitialize,把 DispatcherServlet 與 Filter 註冊到外部容器提供的 ServletContext 上。也就是說,無論內嵌還是外部的 Tomcat,Spring boot 最後都是用同一套 ServletContextInitializer 完成註冊,差別只在於「Tomcat 是誰 new 出來的」而已。
期待後續各位的閱讀與分享,我是 Pax,我們明天見。